# 二进制解码 string_decoder
30 秒速记
- 解决的问题:多字节字符被切在两个
chunk之间,分别toString()会出乱码 StringDecoder把不完整的尾巴字节先存起来,等下一块到了拼完整再输出- 两个
API:write(buf)返回能完整解码的部分,end()吐出剩余内容 - 平时少直接用:
stream.setEncoding('utf8')内部就是它,浏览器和新代码可用TextDecoder+{ stream: true } - 只解决「字符被切断」,解决不了「编码选错」
string_decoder 专门解决一件事:网络或文件流把一个汉字切成了两半,直接 toString() 会乱码。 比如「好」是 e5 a5 bd 三个字节,第一个 chunk 只带了前两个,单独解码就成了 �。StringDecoder 的做法是把凑不齐的尾巴先留在内部,下次 write 时拼上再输出,所以拼出来的字符串永远是完整的。实际项目里我很少手写它,给可读流调一下 setEncoding('utf8') 就是同样的效果,浏览器那边对应的是 TextDecoder 的流式模式。
理解「二进制解码 string_decoder」时,要先明确输入、运行阶段和可观察输出。这里的交互实验会分别执行正常路径与失败路径,并把 Node.js 的关键状态变化和同屏代码对应起来;不能只记命令名称。
工程中还要验证退出码、资源清理和异常输入。属于 buffer 场景的配置应保持最小可复现,并在开发环境与 CI 中使用相同命令复跑,避免把平台差异或隐式默认值带到线上。
可以用一个最小例子看出直接 toString() 和 StringDecoder 的区别:
const { StringDecoder } = require('string_decoder');
// '好' = e5 a5 bd,被切成两块
const c1 = Buffer.from([0xe4, 0xbd, 0xa0, 0xe5, 0xa5]);
const c2 = Buffer.from([0xbd]);
console.log(c1.toString() + c2.toString()); // 你��(具体几个 � 不重要,总之坏了)
const decoder = new StringDecoder('utf8');
console.log(decoder.write(c1) + decoder.write(c2) + decoder.end()); // 你好
第一块只解码出「你」,e5 a5 被留在内部;第二块到了以后补齐成 e5 a5 bd,输出「好」。最后的 end() 用来收尾,如果还有凑不齐的字节,会变成替换字符 � 返回。
同样的思路在浏览器里是 TextDecoder:
const td = new TextDecoder('utf-8');
let text = td.decode(c1, { stream: true }); // '你'
text += td.decode(c2, { stream: true }); // '好'
text += td.decode(); // 收尾
除了 utf8,StringDecoder 也支持 utf16le(两字节对齐)和 base64(按 3 字节一组对齐)等编码,原理一样:凑不齐一组的先不输出。
面试官追问
追问 1为什么不能 chunks.map(c => c.toString()).join('')?
每个 chunk 是按字节切的,不管字符边界。一个三字节汉字被切成 2 + 1,两边各自解码都是非法序列,会变成 �,拼起来也救不回来。要么先 Buffer.concat 再整体解码,要么用 StringDecoder 边收边解。
追问 2我直接 Buffer.concat 最后一次性解码,还需要 StringDecoder 吗?
数据量小、能等全部收完就不需要,这样最简单。StringDecoder 的价值在于边收边处理,比如按行解析日志流、处理 SSE 推送,不能等全部到齐。
追问 3readable.setEncoding('utf8') 和自己用 StringDecoder 有区别吗?
效果一样,setEncoding 内部就是创建了一个 StringDecoder,之后 data 事件拿到的就是字符串而不是 Buffer。注意设置之后就拿不到原始字节了,处理二进制的流别调它。
追问 4浏览器里读 fetch 的流式响应,有类似的东西吗?
有,TextDecoder。decoder.decode(chunk, { stream: true }) 会把不完整的尾巴留着,最后再调一次不带参数的 decoder.decode() 收尾,和 write / end 是一个意思。现在 Node.js 里也能用 TextDecoder。
追问 5用了 StringDecoder 还是乱码,可能是什么原因?
多半是编码本身选错了,比如数据是 GBK,你按 utf8 去解。StringDecoder 只负责「别把字符切断」,不负责猜编码,构造时传的编码必须和数据真实编码一致。
# 模块简介
30 秒速记
- 用法:
new StringDecoder('utf8'),然后反复decoder.write(buf),拿到的都是完整字符 - 内部维护一个小缓存,存着上一块末尾凑不齐的字节,最多几个字节
UTF-8首字节就能看出这个字符一共几个字节,所以解码器知道还差多少- 典型场景:
socket/HTTP包体 / 子进程输出这种按字节切块的文本流 - 返回空字符串
''不是出错,说明这块数据全是半个字符,在等下一块
StringDecoder 就像一个会「等一等」的 toString:凑不成完整字符的字节先扣下,下次补齐了再交出来。 它能做到这点,是因为 UTF-8 的首字节自带长度信息,110xxxxx 表示两字节、1110xxxx 表示三字节,看到 e5 就知道后面还要两个。所以每次 write 时它检查末尾是不是一个没写完的字符,没写完就存进内部缓存,只返回前面完整的部分。处理 socket、子进程 stdout 这类按字节切块的文本流,用它就不会在边界上出乱码。
string_decoder模块用于将Buffer转成对应的字符串。使用者通过调用stringDecoder.write(buffer),可以获得buffer对应的字符串。
它的特殊之处在于,当传入的buffer不完整(比如三个字节的字符,只传入了两个),内部会维护一个internal buffer将不完整的字节cache住,等到使用者再次调用stringDecoder.write(buffer)传入剩余的字节,来拼成完整的字符。
这样可以有效避免buffer不完整带来的错误,对于很多场景,比如网络请求中的包体解析等,非常有用。
面试官追问
追问 1decoder.write(Buffer.from([0xe5])) 返回什么?
返回空字符串 ''。e5 是一个三字节字符的开头,凑不齐就先存着,等后面两个字节到了才输出。代码里别把空字符串当成「流结束」。
追问 2解码器怎么知道一个字符还差几个字节?
看首字节的高位:0xxxxxxx 是单字节,110xxxxx 两字节,1110xxxx 三字节,11110xxx 四字节,后续字节都是 10xxxxxx。所以看到 e5(11100101)就知道还要再来两个。
追问 3一个 StringDecoder 实例能给多个连接共用吗?
不行,它是有状态的,内部存着上一块的尾巴。两个连接的数据混着写进去,A 的半个字符会和 B 的字节拼在一起。一条流一个实例。
追问 4子进程 stdout 打出来的中文偶尔有乱码,你会怎么修?
child.stdout.setEncoding('utf8'),或者自己 new StringDecoder('utf8') 在 data 回调里 write。偶发乱码基本就是字符被切在了两个 chunk 之间,块大小一变就时有时无。
追问 5按行解析日志流,用了 StringDecoder 是不是就够了?
还不够,它只保证字符完整,不保证行完整。还要自己缓存最后一段没遇到换行的文本,或者直接用 readline.createInterface({ input }),它两件事都帮你做了。
# 入门例子
30 秒速记
write(buf):返回能完整解出的字符,残缺的尾巴留在内部end():把内部剩下的吐出来;残缺字节会变成�,之后这个实例回到初始状态end(buf)= 先write(buf)再end(),返回两者拼起来的结果<Buffer e4 bd a0>解出「你」,e4 bd a0 e5 a5只解出「你」,e5 a5等着- 流结束时一定要调一次
end(),否则最后一点内容可能被吞掉
StringDecoder 常用的就两个方法:write 负责边收边解,end 负责收尾。 write 每次只返回完整的字符,比如传入「你好」的前五个字节,它返回「你」,「好」的前两个字节先扣着。end 在流结束时调,它会把内部剩下的东西全部吐出来,带参数调用就相当于先 write 再 end。我写的时候会固定在 end 事件里调一次 decoder.end()
